| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- import fs from "node:fs";
- import fsp from "node:fs/promises";
- import path from "node:path";
- import { Readable } from "node:stream";
- import { getSession } from "@/lib/auth/session";
- import { canAccessBranch } from "@/lib/auth/permissions";
- import {
- withErrorHandling,
- badRequest,
- unauthorized,
- forbidden,
- notFound,
- ApiError,
- } from "@/lib/api/errors";
- import { mapStorageReadError } from "@/lib/api/storageErrors";
- export const dynamic = "force-dynamic";
- export const runtime = "nodejs";
- const BRANCH_RE = /^NL\d+$/;
- const YEAR_RE = /^\d{4}$/;
- const MONTH_RE = /^(0[1-9]|1[0-2])$/;
- const DAY_RE = /^(0[1-9]|[12]\d|3[01])$/;
- function getNasRootOrThrow() {
- const root = process.env.NAS_ROOT_PATH;
- if (!root) {
- throw new ApiError({
- status: 500,
- code: "FS_STORAGE_ERROR",
- message: "Internal server error",
- });
- }
- return root;
- }
- function isSafeFilename(name) {
- if (typeof name !== "string") return false;
- const trimmed = name.trim();
- if (!trimmed) return false;
- // Reject special path segments
- if (trimmed === "." || trimmed === "..") return false;
- // Reject any path separators (defense-in-depth)
- if (trimmed.includes("/") || trimmed.includes("\\")) return false;
- // Reject control chars (header injection)
- if (/[\r\n\t]/.test(trimmed)) return false;
- // Reject quotes to keep Content-Disposition predictable/safe
- if (trimmed.includes('"')) return false;
- // Ensure it's a basename (no sneaky segments)
- if (path.basename(trimmed) !== trimmed) return false;
- return true;
- }
- function isPdfFilename(name) {
- return typeof name === "string" && name.toLowerCase().endsWith(".pdf");
- }
- function validateParamsOrThrow({ branch, year, month, day, filename }) {
- if (!BRANCH_RE.test(branch)) {
- throw badRequest("VALIDATION_BRANCH", "Invalid branch parameter", {
- branch,
- });
- }
- if (!YEAR_RE.test(year)) {
- throw badRequest("VALIDATION_YEAR", "Invalid year parameter", { year });
- }
- if (!MONTH_RE.test(month)) {
- throw badRequest("VALIDATION_MONTH", "Invalid month parameter", { month });
- }
- if (!DAY_RE.test(day)) {
- throw badRequest("VALIDATION_DAY", "Invalid day parameter", { day });
- }
- if (!isSafeFilename(filename)) {
- throw badRequest("VALIDATION_FILENAME", "Invalid filename parameter", {
- filename,
- });
- }
- if (!isPdfFilename(filename)) {
- throw badRequest(
- "VALIDATION_FILE_EXTENSION",
- "Only PDF files are allowed",
- { filename }
- );
- }
- }
- function resolvePdfPathOrThrow({ root, branch, year, month, day, filename }) {
- const rootAbs = path.resolve(root);
- const absPath = path.resolve(rootAbs, branch, year, month, day, filename);
- // Ensure the resolved path stays within NAS_ROOT_PATH
- const rel = path.relative(rootAbs, absPath);
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
- throw badRequest("VALIDATION_PATH_TRAVERSAL", "Invalid file path", {
- branch,
- year,
- month,
- day,
- filename,
- });
- }
- return absPath;
- }
- /**
- * Content-Disposition helper (Unicode-safe).
- *
- * Problem:
- * - Node's Web Response headers require ByteString-compatible values.
- * - Unicode characters (e.g. "€") in `filename="..."` can crash the response creation.
- *
- * Solution:
- * - Provide an ASCII fallback via `filename="..."`.
- * - Provide the real UTF-8 name via RFC 5987: `filename*=UTF-8''...`.
- */
- function stripDiacritics(input) {
- return String(input)
- .normalize("NFKD")
- .replace(/[\u0300-\u036f]/g, "");
- }
- function toAsciiFallbackFilename(filename) {
- // Keep it predictable and safe for headers: ASCII only.
- // We also keep the .pdf extension if possible.
- const raw = stripDiacritics(filename);
- const ascii = raw
- .replace(/[^\x20-\x7E]/g, "_") // replace non-ASCII with underscore
- .replace(/\s+/g, " ") // collapse whitespace
- .replace(/_+/g, "_") // collapse underscores
- .trim();
- if (!ascii) return "download.pdf";
- if (!ascii.toLowerCase().endsWith(".pdf")) return `${ascii}.pdf`;
- return ascii;
- }
- function encodeRFC5987ValueChars(str) {
- // RFC 5987 encoding for header parameters:
- // Use percent-encoded UTF-8 bytes and additionally encode a few chars that
- // encodeURIComponent leaves as-is but can be problematic in headers.
- return encodeURIComponent(str)
- .replace(/['()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
- .replace(/\*/g, "%2A");
- }
- function buildContentDisposition(filename, asAttachment) {
- const type = asAttachment ? "attachment" : "inline";
- const fallback = toAsciiFallbackFilename(filename);
- const encoded = encodeRFC5987ValueChars(filename);
- return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
- }
- /**
- * GET /api/files/:branch/:year/:month/:day/:filename
- *
- * Query (optional):
- * - download=1 | download=true => Content-Disposition: attachment
- * - default => inline
- */
- export const GET = withErrorHandling(
- async function GET(request, ctx) {
- const session = await getSession();
- if (!session) {
- throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
- }
- const { branch, year, month, day, filename } = await ctx.params;
- const missing = [];
- if (!branch) missing.push("branch");
- if (!year) missing.push("year");
- if (!month) missing.push("month");
- if (!day) missing.push("day");
- if (!filename) missing.push("filename");
- if (missing.length > 0) {
- throw badRequest(
- "VALIDATION_MISSING_PARAM",
- "Missing required route parameter(s)",
- { params: missing }
- );
- }
- if (!canAccessBranch(session, branch)) {
- throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
- }
- validateParamsOrThrow({ branch, year, month, day, filename });
- const root = getNasRootOrThrow();
- const absPath = resolvePdfPathOrThrow({
- root,
- branch,
- year,
- month,
- day,
- filename,
- });
- const details = { branch, year, month, day, filename };
- let stat;
- try {
- stat = await fsp.stat(absPath);
- } catch (err) {
- throw await mapStorageReadError(err, { details });
- }
- if (!stat.isFile()) {
- throw notFound("FS_NOT_FOUND", "Not found", details);
- }
- const { searchParams } = new URL(request.url);
- const download = (searchParams.get("download") || "").toLowerCase();
- const asAttachment = download === "1" || download === "true";
- const contentDisposition = buildContentDisposition(filename, asAttachment);
- const nodeStream = fs.createReadStream(absPath);
- const webStream = Readable.toWeb(nodeStream);
- return new Response(webStream, {
- status: 200,
- headers: {
- "Content-Type": "application/pdf",
- "Content-Disposition": contentDisposition,
- "Content-Length": String(stat.size),
- "Cache-Control": "no-store",
- "X-Content-Type-Options": "nosniff",
- },
- });
- },
- { logPrefix: "[api/files/[branch]/[year]/[month]/[day]/[filename]]" }
- );
|